2.5 堅牢なクラス設計
どんなプログラミング言語でもそうですが,文法とライブラリの使い方を覚えただけではまともなプログラムを書くことはできません.C++ももちろんそうです.ここでは,堅牢なクラスを設計するための基本的なノウハウについて解説することにします.
2.5.1 クラスのメンバーは可能なかぎり非公開にしよう
クラスのメンバーは,アクセス指定子によって,非公開(private),限定公開(protected),公開(public)のいずれかにすることができます.公開または限定公開のメンバーが1つもないクラスは役に立ちませんが,それでもメンバーはできるかぎり非公開にするように努力しましょう.そして,必要最小限のものだけを公開または限定公開にするようにしましょう.
たとえば,データメンバーを公開してしまうと,そのクラスのメンバー関数以外からでも自由にデータメンバーを変更できてしまいます.多くの場合,データメンバーをメンバー関数で変更する場合には矛盾がないように注意深く実装されるので,勝手に他から更新されるとたちまち破綻してしまいます.これを防ぐには,各メンバー関数の最初で矛盾がないことをチェックしなければならず,それだけ実装の手間も増えますし,オーバーヘッドにも繋がります.
クラスの設計に慣れないうちは,何を非公開にすべきかを考えがちですが,そうではなく,原則はすべて非公開にすべきであり,必要最小限のものだけやむをえず公開すべきなのです.また,最初にクラス設計を行った際には非公開になっていたメンバーを外からアクセスしようとしてコンパイルエラーになったため,強引にコンパイルを通すことのみを目的として,そのメンバーが公開になるようにクラスを設計変更してしまうプログラマーも少なからずいます.これは最悪ですので,心当たりのある方は十分ご注意ください.
もっとも,データメンバーを公開しても害がない場合もあります.たとえば,何度も例として取り上げている複素数クラスなどがそうです.複素数クラスのデータメンバーである実部と虚部の値は,外部から変更されたとしても何の問題もありません.
struct complex
{
double real;
double imag;
complex(double r = 0, double i = 0)
: real(r), imag(i) // ← データメンバーの初期化
{
}
}
こうしたクラスでも,上記のコードのようにコンストラクタがあれば便利ですし,いくつかの演算子を多重定義するなど,メンバー関数を持たせれば立派なクラスとなるのですが,データメンバーを隠蔽することにはほとんど意味がないでしょう.次の座標クラスのような場合も同様です.
struct point
{
int x;
int y;
explicit point(int xpos, int ypos)
: x(xpos), y(ypos)
{
}
};
仮に,これらのクラスのデータメンバーを非公開にした場合,結局のところ,それぞれのデータメンバーの値を取得,設定するためのメンバー関数を公開せざるをえず,使い勝手が低下し,実装の手間が増えるだけで,何のメリットもありません.
class point
{
public:
…
int get_x() const
{
return x;
}
void set_x(int pos)
{
x = pos;
}
…
private:
int x;
int y;
};
上記のような設計では,xを1つだけ公開すれば済むところを,get_xとset_xの2つを公開している分,必要最小限を超えるメンバーを公開してしまっていると考えることもできます.
また,座標クラスの場合,内部では直交座標の成分を保持し,メンバー関数で極座標を取り出せるようにするといった設計をしたがる人もいます.しかし,直交座標の各成分がわかれば,極座標を求めるのは非メンバー関数で十分ですので,そのような設計は避けるべきです.
2.5.2 継承されるクラスのデストラクタは, 仮想関数にするか限定公開にしよう
C++で継承を使い始めると,最初に待っている罠のような現象があります.まずは具体例から見ていきましょう.
class A
{
public:
virtual void func();
…
};
class B : public A
{
public:
virtual void func();
…
};
int main()
{
A* p = new B;
…
p->func();
…
delete p; // ← 未定義の動作が発生!
return 0;
}
この例では,動的に生成されたBクラスのオブジェクトを,基底クラスであるAクラスへのポインタを使って操作しようとしています.仮想関数であるfuncの呼び出しなどは問題なく行うことができますが,最後の最後に,オブジェクトをdelete演算子で解体しようとしたときに問題が発生します.しかも,未定義の動作ですので,直ちにクラッシュするとはかぎらず,後になってから原因不明の不具合が現れたりすることも少なくないのです.
問題の原因は,Aクラスへのポインタを用いてBクラスのオブジェクトを解体しようとしたことにあります.コンパイラは静的な型しか知るすべがありませんので,Aクラスへのポインタに対してdeleteを行おうとした場合には,Aクラスのデストラクタしか呼び出しません.仮にBクラスに明示的なデストラクタが定義されていなかったとしても,コンパイラが暗黙的に生成する解体処理がありますし,AクラスとBクラスではサイズが異なるかもしれませんので,メモリを解放するときに不具合が生じるかもしれないのです. この問題を解決するには,通常,デストラクタを仮想関数にします.
class A
{
public:
virtual ~A()
{
}
…
};
上記のように,基底クラスのデストラクタをvirtual指定子を付けて仮想関数にしておけば,基底クラスへのポインタを用いてdeleteを行った場合でも正しく派生クラスのデストラクタが呼び出されます.多相クラスを実装するときは,決まり文句として,デストラクタを仮想関数にしておくべきです.
しかし,場合によってはデストラクタを仮想関数にしたくないこともあります.具体的には,多相性を実現することを目的としない継承を行う場合などがそうです*15.その場合には,デストラクタを限定公開(protected)にしましょう.そうすることで,基底クラスへのポインタを用いてdeleteを行うことができなくなります.派生クラスのオブジェクトの解体には,必ず派生クラスへのポインタを使ってdeleteしなければならなくなるのです.
class A
{
protected:
~A()
{
}
…
};
class B : public A
{
…
};
int main()
{
A* p = new B;
delete p; // ← エラー!(A::~Aにアクセスできない)
return 0;
}
2.5.3 new演算子をカスタマイズしよう
組込み開発では,malloc関数のような可変長の動的割り付けは避けられる傾向にあります.割り付けと解放を繰り返していると,やがてヒープの断片化が起こり,必要な割り付けができなくなってしまうからです.同じ理由で,new演算子の使用も避けてしまいがちです.しかし,もしそのクラス専用のnew演算子とdelete演算子を定義できたらどうでしょう.そのクラス専用ですから,割り付けるべきサイズは固定で済みます.
さっそく試してみましょう.
class A
{
public:
static void* operator new(size_t size)
{
VP result;
while (get_mpf(MPFID, &result) < 0 && new_handler_ != 0)
{
(*new_handler_)();
}
return result;
}
static void operator delete(void* ptr) throw()
{
rel_mpf(MPFID, ptr);
}
static new_handler set_new_handler(new_handler handler) throw()
{
new_handler old = new_handler_;
new_handler_ = handler;
return old;
}
private:
static void (*new_handler_)();
};
ここでは実装例として,μITRONの可変長メモリプールを使用してみました.get_mpfでメモリブロックを割り付け,rel_mpfでメモリブロックを解放することができます.割り付けられるメモリブロックのサイズは,メモリプールの生成時にsizeof(A)以上に設定しておく必要があります.
それぞれのメンバー関数について少し詳しく解説することにしましょう.new演算子は,size_t型の引数を受け取りvoid*型を返します.例外指定(「3.8 例外指定の振る舞い」参照)は必ずthrow(bad_alloc)としなければなりません*16.new演算子の形式はこのように決まっており,勝手に変更すべきではありません.そして,割り付けに失敗した場合には,set_new_handlerで登録したエラー処理用のハンドラを呼び出します.
delete演算子は,new演算子が返したポインタを受け取り,それが指すメモリブロックを解放します.例外指定はthrow()であり,決して例外を送出してはなりません.
最後に,set_new_handler関数も用意します.これは先ほど説明したように,new演算子がメモリブロックの割り付けに失敗したときに呼び出すハンドラを登録するためのものです.set_new_handlerは登録するハンドラを受け取り,直前に登録されていたハンドラを返します.set_new_handlerは必須ではありませんが,new演算子をカスタマイズする際の作法として,一緒にカスタマイズしておいたほうがよいでしょう.
これで一応カスタマイズしたnew演算子が使えるようになりました.しかし,行うべき作業はこれだけではありません.実は,new演算子とdelete演算子にはいくつかのバリエーションがあります.それぞれのバリエーションをひととおり挙げてみることにします.
【new演算子とdelete演算子のバリエーション】
void* operator new(size_t size) throw(bad_alloc);
void operator delete(void* ptr) throw();
void* operator new(size_t size, const nothrow_t&) throw();
void operator delete(void* ptr, const nothrow_t&) throw();
void* operator new(size_t size, void* ptr) throw();
void operator delete(void* ptr, void*) throw();
void* operator new[](size_t size) throw(bad_alloc);
void operator delete[](void* ptr) throw();
void* operator new[](size_t size, nothrow_t) throw();
void operator delete[](void* ptr, const nothrow_t&) throw();
void* operator new[](size_t size, void*) throw();
void operator delete[](void* ptr, void*) throw();
最初のAの組についてはすでに定義を行いました.
2番目のBの組はnothrow_t型のconst参照を受け取ります.この形式では,new演算子がメモリブロックの割り付けに失敗した場合でも例外を送出することはなく,代わりに空ポインタ(NULL)を返します.
このようなnew演算子は「生成位置指定の構文」または「配置構文」と呼ばれ,次のように記述します.
【生成位置指定の構文】
A* p = new(nothrow) A;
また,nothrow_t版のnewおよびdelete演算子は次のように実装します.
【new演算子とdelete演算子の実装例】
void* operator new(size_t size, const nothrow_t&) throw()
{
void* result;
try
{
result = operator new(size);
}
catch (...)
{
result = 0;
}
return result;
}
void operator delete(void* ptr, const nothrow_t&) throw()
{
operator delete(ptr);
}
3番目のCの組では,void*型の引数を受け取ります.この形式では,new演算子はメモリブロックを割り付けませんし,delete演算子はメモリブロックを解放しません.すなわち,void*型の引数には別途割り付けられたメモリブロックを指定するのです.
【メモリブロックを指定したnew演算子の使用例】
char block[sizeof(A)]; // ← 境界調整には配慮していないので注意
A* p = new(block) A;
4番目のDから6番目のFの組は,それぞれAからCの組の配列版です.単体用のnewおよびdelete演算子と配列用のものは異なるので,それぞれについて別途定義する必要があります.配列用のものについては,配列の要素数によって割り付けるべきサイズが変わるので,どうしても可変長割り付けにせざるをえません.また,配列用のnewはいろいろと問題も多いので,(定義は必要ですが)できれば使用は避けたほうがよいでしょう.
newおよびdelete演算子は,実はこの6組以外にも定義することができます.たとえば,もう一度μITRONの固定長メモリプールを使って,下記のように,タイムアウト時間tmoを指定するような版を作ることも可能です.対応するdelete演算の定義も忘れないようにしてください.
void* operator new(size_t size, TMO tmo) throw(bad_alloc)
{
VP result;
while (tget_mpf(MPFID, &result, tmo) < 0 && new_handler_ != 0)
{
(*new_handler_)();
}
return result;
}
void operator delete(void* ptr, TMO) throw()
{
operator delete(ptr);
}
最後に,delete演算子は,new演算子のように追加の引数を指定することができません.すなわち,単体用のdeleteと配列用のdelete[]以外は明示的に使用することができないのです.では,なぜそれぞれの版が必要になるかを説明することにします.
【例外を送出しないnew演算子の使用例】
A* p = new(nothrow) A;
上記のような場合,new演算子がメモリブロックの割り付けに失敗した場合には例外を送出しませんが,Aクラスのコンストラクタが失敗した場合には例外が送出される可能性があります.その場合,operator deleteが実行されなければメモリリークが発生してしまいます.ここで,暗黙的に呼び出されるoperator deleteというのが,nothrow_t版のoperator deleteなのです.呼び出されるのはあくまでもoperator deleteであって,delete演算子ではありませんので,Aクラスのデストラクタが実行されないことに注意してください.
2.5.4 オブジェクトをコピーすべきでないクラスは, コピーを禁止しよう
C++のクラスは,明示的に定義しなくても,各データメンバーを単純にコピーするためのコピーコンストラクタとコピー代入演算子が暗黙的に定義されます.しかし,外部リソースを操作するためのクラスなど,オブジェクトのコピーができてしまうと不都合がある場合があります.そのような場合,デフォルトのコピー動作を禁止しなければなりません.
デフォルトのコピー動作を禁止するには,次のような慣用手法を用います.
【コピーを禁止するための慣用手法】
class A
{
public:
…
private:
A(const A&); // ← コピーコンストラクタ
A& operator=(const A&); // ← コピー代入演算子
};
Aクラスのコピーコンストラクタとコピー代入演算子は,非公開(private)になっています.そして,ここが重要なのですが,これらコピーコンストラクタとコピー代入演算子は,宣言だけを行い,どこにも定義を行いません.これらの宣言は,外部からコピー操作を行うためのメンバーを隠蔽することだけを目的としているからです.そして,次のように,間違ってコピーを行おうとするとコンパイル時にエラーが発生します.
int main()
{
A a;
A b(a); // ← エラー!
A c;
c = a; // ← エラー!
return 0:
}
C++では,間違った使い方をしたときにコンパイルエラーが発生するように実装することが非常に重要なテクニックとなります.
2.5.5 継承ではなく,データメンバーにできないか検討しよう
C++のクラスの継承は便利な言語仕様ですが,乱用は禁物です.特に,コーディング量を減少させるためだけに継承を使うのは多くの場合間違っています.継承を使うのは,多相性を利用することが大きな目的であり,本来実装を再利用するためのものではないからです.
オブジェクト指向に関する話題の中で,よく継承は「is-a」の関係であり,「has-a」の関係にあるものは継承ではなく「包含」,すなわちデータメンバーを利用すべきだといわれます.初めて聞くと何のことかさっぱりわかりませんが,要するにこういうことです.
基底クラスをA,派生クラスをBとするとき,Bは一種のAであるならば「is-a」の関係にあるといえます.たとえば,Aがタイマーであり,Bが16ビットタイマーだとすると,16ビットタイマーであるBは一種のA(タイマー)であるといえます.それに対して,周期処理を実行するクラスの場合には,一種のタイマーとはいえず,内部でタイマーを持っているにすぎません.この場合は「has-a」の関係になるわけです.
次は,継承を用いた16bit_timerクラスと,包含を用いたcyclicクラスの定義例です.
class timer
{
public:
void start();
void stop();
…
};
class 16bit_timer : public timer // ← 16bit_timer "is a" timer
{
…
};
class cyclic
{
public:
void start();
void stop();
…
private:
timer* tm; // ← cyclic "has a" timer
…
};
注意しなければならないのは,これはC++のクラスを用いてオブジェクト指向プログラミング上の概念である「クラス」を実装する場合の話です.そうではない例も紹介することにしましょう.
template <class Derived>
class fixed_sized_new_base
{
protected:
~fixed_sized_new_base()
{
}
public:
static void* operator new(size_t size) throw(bad_alloc);
static void operator delete(void* ptr, size_t size) throw();
…
};
class somthing : public fixed_sized_new_base<something>
{
…
};
やや難しいコードですが,これは固定長アロケータを用いたnewおよびdelete演算子を提供するためのfixed_sized_new_baseクラステンプレートを用いてsomthingクラスを実装する例です.この場合,基底クラスであるfixed_sized_new_base
もう少し簡単な例も見てみましょう.
class non_copyable
{
private:
non_copyable(const non_copyable&);
non_copyable& operator=(const non_copyable&);
};
class something : public non_copyable
{
…
};
int main()
{
something a;
something b(a); // ← エラー!(コピーできない)
something c;
c = a; // ← エラー!(コピーできない)
return 0;
}
今度の例では,前項「コピーを禁止するための慣用手法」で解説したコピーを禁止するための仕掛けを提供するnon_copyableクラスを用いて,somethingクラスをコピー禁止にしています.この場合も,non_copyableクラスには多相性を実現する意図はまったくありません.
これらの使い方は,オブジェクト指向プログラミングにおける継承と,C++の言語仕様としての継承が,必ずしも等価ではないことの例でもあります.
2.5.6 メンバー関数ではなく,非メンバー, 非随伴関数にできないか検討しよう
オブジェクト指向プログラミングに対するよくある誤解に,データとそれを操作する手続きは必ずひとまとめにしなければならないというのがあります.
「2.1.3 オブジェクト指向プログラミングの概要」のカプセル化の解説の中でも触れましたが,1つのクラスになんでもかんでもまとめてしまおうとするのは間違っています.カプセル化を行う際は,抽象化を実現し,破綻をきたすことがない最小限のものだけをコンパクトにまとめたほうがよいのです.
クラスには最小限のメンバー関数だけを持たせ,それらのメンバー関数を使って実装可能な場合は,非メンバー関数にするほうがなにかと融通が利き,拡張性にも優れています.また,非メンバー関数にするといっても,随伴(friend)関数にしなければ実現できないようであれば,特別な事情がないかぎりメンバー関数にしたほうがよいでしょう. なお,他のメンバー関数を用いて実現できたとしても,クラス内部のデータメンバーに直接アクセスできなければ効率が非常に悪くなるような場合も,メンバー関数にせざるをえません.このように,つねに非メンバー,非随伴関数にするのが良いわけではありませんが,原則はあくまでも非メンバー,非随伴関数であると考えてください.
具体例を挙げることにしましょう.
class counter
{
public:
counter(int val)
: value(val)
{
}
void reset(int val)
{
value = val;
}
void up()
{
++value;
}
void down()
{
--value;
}
int get() const
{ return vaue;
}
void event(int val, void (*handler)()) // ← 余計なメンバー関数
{
if (value == val)
{
(*handler)();
}
}
private:
int value;
};
上記の例は単純なカウンタクラスです.カウンタ値のリセットを行うreset,カウントアップを行うup,カウントダウンを行うdown,現在値を取得するgetは必須のメンバー関数ですが,カウント値が指定した値であればハンドラを呼び出すeventはメンバー関数でなくても容易に実現することができます.このような余計なものはメンバー関数にすべきではなく,非メンバー,非随伴関数とすべきです.
2.5.7 暗黙の型変換をなるべく回避しよう
「2.2.1 コンストラクタとデストラクタ」でも解説しましたが,クラスに変換コンストラクタを定義すると,別の型から暗黙的な型変換によってそのクラスのオブジェクトを生成することができます.逆に,そのクラスのオブジェクトから別の型に変換できるようにすることもできます.
次のコードを見てください.
class A
{
public:
operator int() const;
…
};
上記の例のように,変換対象の型名の演算子を多重定義すれば,次のように暗黙の型変換が可能になります.このような型変換のための演算子を「変換関数」といいます.
A a;
int i = a;
この言語仕様はたいへん便利なのですが,使用する際には細心の注意を払う必要があります.というのも,暗黙の型変換を許してしまうと,意図しないところで型変換が発生してしまい,思わぬ不具合に繋がりかねないからです.
また,次のような多義性の問題も発生します.
class B;
class A
{
public:
A(const B& b);
};
class B
{
public:
operator A() const;
};
void func(const A& a);
int main()
{
B b;
func(b); // ← エラー!(曖昧)
return 0;
}
上記のコードは少々取って付けたような例ですが,安易に変換コンストラクタや変換関数を使うと,実際の開発現場では忘れた頃にこの問題に直面し,その時点で対応するのはかなり骨が折れます.変換コンストラクタや変換関数は,たとえば,自作の多倍長整数クラスと既存の算術型(long型やdouble型など)との相互変換のような,直感的に振る舞いが理解しやすいものに限るべきです.
2.5.8 クラスが内部で管理するハンドルを 公開しないようにしよう
ここでいう「ハンドル」というのは,実際にハンドルという名前の付くものだけではありません.クラスが内部で管理しているリソースを操作するための情報のことです. 具体的な例を見てみましょう.
class table
{
public:
FILE* get_file() const
{
return file;
}
…
private:
FILE* file;
…
};
int main()
{
table obj;
…
FILE* file = obj.get_file();
fclose(file); // ← 破綻!
…
return 0;
}
上記の例は,ファイル上に格納された何らかのテーブルを扱うクラスです.ファイルを扱うので,内部でFILE型へのポインタを保持しています.しかし,get_file関数でそのポインタを外部から取得できてしまいます.取得したポインタを使ってftell関数などでそのストリームの情報を取得する程度であればよいのですが,入出力を行ったり,クローズしたりすれば,たちまち破綻してしまいます.
あるいは,下記のような文字列クラスを考えてみましょう.
class string
{
public:
char* get_str() const
{
return buffer;
}
…
private:
char* buffer;
size_r length;
};
int main()
{
string str;
…
char* s = str.get_str();
s[0] = '\0'; // ← 破綻!
…
return 0;
};
内部的に管理している文字列格納用のバッファへのポインタbufferを,get_str関数を使えば外部から取得することができてしまいます.このポインタを使って,バッファに値を書き込んだり,deleteしたりすればやはり破綻してしまいます.
しかし,文字列クラスの場合には,Cスタイルの文字列を使わなければならないケースも多いため,どうしてもchar*への変換が必要になります.その場合には,char*ではなく,せめてconst修飾されたconst char*を返せば,かなり改善されます.
ところが,Cスタイルの文字列を要求する関数や構造体などが,const char*ではなくchar*を要求する場合もあります.実際に書き換えが発生するためにchar*になっているのであれば文字列をコピーするしかありませんが,多くの場合,書き換えが発生しないにもかかわらず,単にconst修飾を怠っているだけのケースもよく見かけます.このような場合,const_castを使ってconst修飾子を外すしかありません.それが危険なコードであることを示すために,(char*)ではなくconst_cast
2.5.9 例外安全なコードを書こう
C++では,例外処理の機能があるため,ありとあらゆるところから例外が送出される可能性があります.例外指定によって例外が送出されないことを明示しているもの以外は,関数の呼び出しのほか,オブジェクトの生成(一時オブジェクトを含む),多重定義した演算子の実行でも例外が送出されます.
このように,C++のコードの実行パスは,見た目よりずっと複雑なのです.そして,どのような実行パスを通ったとしても,メモリリークや状態の矛盾などが発生しないようにしなければなりません.欲をいえば,例外が発生した場合にはその処理を実行する直前の状態を復元できるようにするのが最も望ましいのです.
このように,例外が送出された場合の安全対策のことを「例外安全」といいます.マルチスレッド環境ではスレッド安全(スレッドセーフ)に配慮しますが,それと同様に,例外に対する安全性にも配慮する必要があるのです. まずは簡単な例から見ていきましょう.
A* p = new A;
p->func(); < ここで例外が送出されれば次のdeleteは実行されないため,リークが発生する
delete p;
上記の例は,最もよくある間違いの1つです.この問題を回避するには,次のようにしなければなりません.
A* p;
try
{
p = new A;
p->func();
delete p;
}
catch (...)
{
delete p;
throw; // ← 同じ例外を再送出
}
別の例を見てみましょう.
class A
{
public:
A& operator=(const A& src)
{
delete pb;
pb = new B(*src.pb); // ← ここで例外が送出されれば,pbはdeleteされた後なので破綻する
return *this;
}
…
private:
B* pb;
};
これもついやってしまいがちな間違いです.この問題を回避するには次のようにします.
class A
{
public:
A& operator=(const A& src)
{
B* temp = new B(*src.pb);
delete pb; // ← deleteは決して例外を送出しない.デストラクタは例外を送出してはならない
pb = temp;
return *this;
}
…
private:
B* pb;
};
このように,例外を送出する可能性がある処理を先に行ってしまい,それらがすべて成功した後に,決して例外を送出しない処理を行うようにします.ところで,コメントにも書いているように,クラスのデストラクタは決して例外を送出してはなりません.デストラクタが例外を送出すると,例外安全なコードを書くことは事実上不可能になってしまいます.また,例外が送出された結果として,いくつかの自動オブジェクトのデストラクタが呼び出されることになるわけですが,そのときに自動オブジェクトのデストラクタが例外を送出すると,「二重例外」となって即座にシステムがクラッシュしてしまいます.そうした理由から,デストラクタからは決して例外を送出してはならないのです.
クラスないしは関数が例外安全であることを保障することを「例外安全保障」といいます.例外安全保障については,「5.3.2 3つの例外安全保障」で詳しく解説することにします.
ところで,ここでは「例外安全」という用語を使いましたが,それは読者がWeb上で検索するときの便宜を考えてのことです.実際には,例外安全保障のようなことは,例外が送出されるか,エラーコードのような別の方法で通知されるかにかかわらず,すべての例外的事象についていえることです.関数がエラーコードを返して失敗したとき,メモリリークや状態の矛盾が発生するような設計や実装を行ってはいけないのは当然のことです.その意味では,例外に対する配慮はなにも特別なものではありません.